iT邦幫忙

2023 iThome 鐵人賽

DAY 17
0
Software Development

FastAPI 開發系列 第 17

使用者驗證 - 測試不可知的事務以符合真實情境 - 1

  • 分享至 

  • xImage
  •  

老獅:接下來我們就依照我們列出的項目開始實作測試,將原本的 test_create_jwt_token_by_username_and_passowrd 進行改寫,這邊我們使用 uuid 來幫我們產生亂數的帳號密碼,用以取代原本我們寫死的 test 帳號密碼,並且將其寫入資料庫

# src/tests/test_services/test_token.py
import uuid

import httpx
import pytest
from fastapi import encoders
from sqlalchemy.ext import asyncio as sqlalchemy_asyncio

from app import main
from app.models import auth as auth_models


@pytest.mark.asyncio
async def test_create_jwt_token_by_username_and_passowrd(
    migrate: None,
    db: sqlalchemy_asyncio.AsyncSession,
):
    username = str(uuid.uuid4())
    password = str(uuid.uuid4())
    user_info = {
        "username": username,
        "password": password,
    }
    obj_in_data = encoders.jsonable_encoder(user_info)
    user = auth_models.User(**obj_in_data)
    db.add(user)
    await db.commit()
    await db.flush()

    async with httpx.AsyncClient(app=main.app, base_url="http://test") as client:
        resp = await client.post(
            "/v1/auth/users/tokens",
            json=user_info,
        )
        assert resp.status_code == 200
        data = resp.json()
        assert (access_token := data["access_token"])
        assert data["refresh_token"]
        resp = await client.get(
            "/v1/auth/users/tokens/info",
            headers={"Authorization": f"Bearer {access_token}"},
        )
        assert resp.status_code == 200
        assert resp.json()["username"] == username
make test
E       fixture 'migrate' not found
>       available fixtures: anyio_backend, anyio_backend_name, anyio_backend_options, cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, event_loop, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory, unused_tcp_port, unused_tcp_port_factory, unused_udp_port, unused_udp_port_factory
>       use 'pytest --fixtures [testpath]' for help on them.

/Users/super/project/fastit/src/tests/test_services/test_token.py:12
=========================== short test summary info ============================
ERROR src/tests/test_services/test_token.py::test_create_jwt_token_by_username_and_passowrd
========================== 3 passed, 1 error in 0.89s ==========================
make: *** [test] Error 1

老獅:我們可以看到他說 migrate 找不到在哪,因為我們 migrate 這個 fixture 目前是寫在 src/tests/test_units/test_users_crud.py 當中,我們要讓 test_create_jwt_token_by_username_and_passowrd 也可以使用

小獅:複製貼上?

老獅:當然不是,我們可以寫在 src/tests/conftest.py 當中,pytest 在執行前會先一個目錄一個目錄的 conftest 都先跑過,才跑該目錄的測試檔案,所以,我們只要寫在最上層,下面的測試都可以吃到他們

# src/tests/conftest.py
import pathlib
import typing
from unittest import mock

import pytest
from sqlalchemy import orm
from sqlalchemy.ext import asyncio as sqlalchemy_asyncio
from sqlalchemy_utils import functions

from core import config


@pytest.fixture
def settings() -> typing.Iterator[config.Settings]:
    settings = config.Settings()
    settings.DATABASE_URL = settings.DATABASE_URL + "_test"
    settings.MODE = "test"
    with mock.patch("core.config.Settings") as Settings:
        Settings.return_value = settings
        yield settings


@pytest.fixture
async def gen_db(
    settings: config.Settings,
) -> None:
    engine = sqlalchemy_asyncio.create_async_engine(
        settings.DATABASE_URL,
        echo=True,
    )
    sessionmaker = orm.sessionmaker(
        bind=engine,
        autocommit=False,
        autoflush=False,
        expire_on_commit=False,
        class_=sqlalchemy_asyncio.AsyncSession,
    )
    async with sessionmaker() as session:
        async with session as db:
            await db.run_sync(
                lambda _: (functions.create_database(settings.DATABASE_URL))
            )
            yield
        async with session as db:
            await db.close()
            await db.run_sync(
                lambda _: (functions.drop_database(settings.DATABASE_URL))
            )


@pytest.fixture
async def migrate(
    settings: config.Settings,
    gen_db: None,
) -> typing.AsyncIterator[None]:
    from alembic import config as alembic_config
    from alembic.runtime.environment import EnvironmentContext
    from alembic.script import ScriptDirectory

    alembic_cfg = alembic_config.Config(file_=str(pathlib.Path("src/app/alembic.ini")))
    alembic_cfg.set_main_option(
        "script_location", str(pathlib.Path("src/app/migrations"))
    )
    script = ScriptDirectory.from_config(alembic_cfg)

    def upgrade(rev, context):
        return script._upgrade_revs("heads", rev)

    context_kwargs = dict(
        config=alembic_cfg,
        script=script,
    )
    upgrade_kwargs = dict(fn=upgrade, starting_rev=None, destination_rev="heads")
    with EnvironmentContext(**upgrade_kwargs, **context_kwargs):
        from app.migrations import env

        await env.run_async_migrations()
        yield


@pytest.fixture
async def db(
    settings: config.Settings,
    gen_db: None,
) -> typing.AsyncIterator[sqlalchemy_asyncio.AsyncSession]:
    engine = sqlalchemy_asyncio.create_async_engine(
        settings.DATABASE_URL,
        echo=True,
    )
    sessionmaker = orm.sessionmaker(
        bind=engine,
        autocommit=False,
        autoflush=False,
        expire_on_commit=False,
        class_=sqlalchemy_asyncio.AsyncSession,
    )
    async with sessionmaker() as session:
        async with session as db:
            yield db
make test
# ... 省略
>           assert resp.json()["username"] == username
E           AssertionError: assert 'test' == '5e5b182f-d41...-cacbd253fb76'
E             - 5e5b182f-d419-4703-a253-cacbd253fb76
E             + test

src/tests/test_services/test_token.py:43: AssertionError

老獅:很好,一如預期的壞了,我們寫死的是無法使用的,接下來我們就可以更改我們的程式令其符合測試,我們可以利用 FastAPI 提供的依賴注入,直接拿到請求的 HTTP Body 資料

# src/app/schemas/users.py
import pydantic


class LoginInfo(pydantic.BaseModel):
    username: str
    password: str

老獅:這樣我們就可以把使用者帳號拿去建立 access_tokenrefresh_token 我們先不做任何事情,就讓他和 access_token

# src/app/api/v1/endpoints/auth/users/tokens.py
from app.schemas import tokens as token_schemas
from app.schemas import users as user_schemas


@router.post("")
def create_jwt_token(
    user: user_schemas.LoginInfo,
):
    access_token = create_access_token(dict(sub=user.username))
    refresh_token = create_access_token(dict(sub=user.username))
    return {"access_token": access_token, "refresh_token": refresh_token}

老獅:老樣子,我們可以設定回傳的 schema 讓前端可以在 docs 頁面了解回傳的資訊

# src/app/schemas/tokens.py
import pydantic


class JWT(pydantic.BaseModel):
    access_token: str
    refresh_token: str
# src/app/api/v1/endpoints/auth/users/tokens.py
from app.schemas import tokens as token_schemas
from app.schemas import users as user_schemas


@router.post("", response_model=token_schemas.JWT)
def create_jwt_token(
    user: user_schemas.LoginInfo,
):
    access_token = create_access_token(dict(sub=user.username))
    refresh_token = create_access_token(dict(sub=user.username))
    return {"access_token": access_token, "refresh_token": refresh_token}

小獅:我們如何實作 create_access_token

老獅:我們可以先簡單的給予 token 過期時間 (exp),以及該 tokensubject (sub) 這邊我們已經預設好,會使用使用者名稱,存在 token

小獅:如果我沒有記錯,token 內是不是不能放機敏資料?

老獅:對的,由於 JWTpayload 中僅僅只有編碼,而非加密,所以我們只能存使用者相關,但是相對不機敏的資料

# src/app/api/v1/endpoints/auth/users/tokens.py

import datetime
import typing

from jose import jwt

from core import config


def create_access_token(
    data: dict, expires_delta: typing.Optional[datetime.timedelta] = None
):
    to_encode = data.copy()
    now = datetime.datetime.utcnow()
    expire = now + datetime.timedelta(minutes=15)
    if expires_delta:
        expire = now + expires_delta
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(
        to_encode, config.Settings().authjwt_secret_key, algorithm="HS256"
    )
    return encoded_jwt

老獅:這邊我們就直接先寫死 exp 為 15 分鐘,演算法我們先給死 HS256,至於用來做 HASH 的密鑰,希望你還記得怎麼拿

make test
# ...省略
>           assert resp.json()["username"] == username
E           AssertionError: assert 'test' == 'df347bdd-5c6...-a6b04d6d3529'
E             - df347bdd-5c60-448d-a57c-a6b04d6d3529
E             + test

src/tests/test_services/test_token.py:43: AssertionError

老獅:很好,我們現在來改 token info 的 API,讓他從 token 中拿取 username,我們就用一樣的方式去解開 token 即可

# src/app/api/v1/endpoints/auth/users/tokens.py
import jose
from fastapi import security


@router.get("/info")
def get_jwt_token_info(
    token: str = fastapi.Depends(security.OAuth2PasswordBearer(tokenUrl="token")),
):
    credentials_exception = fastapi.HTTPException(
        status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(
            token,
            config.Settings().authjwt_secret_key,
            algorithms=["HS256"],
        )
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
    except jose.JWTError:
        raise credentials_exception

    return {"username": username}
make test
pytest .
============================= test session starts ==============================
platform darwin -- Python 3.8.13, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/super/project/fastit
configfile: pyproject.toml
plugins: asyncio-0.21.1, anyio-3.7.1
asyncio: mode=auto
collecting ... /Users/super/project/fastit/src
collected 4 items

src/tests/test_main.py .                                                 [ 25%]
src/tests/test_services/test_token.py .                                  [ 50%]
src/tests/test_units/test_old.py .                                       [ 75%]
src/tests/test_units/test_users_crud.py .                                [100%]
============================== 4 passed in 1.12s ===============================

小獅:我們完全沒用到資料庫,也沒有對密碼耶 @@

老獅:沒錯,所以我們要寫更多測試來完善它,先提交吧

make lint-fix && make lint
git add  src/app/api/v1/endpoints/auth/users/tokens.py
git add  src/app/schemas/tokens.py
git add  src/app/schemas/users.py
git add  src/tests/conftest.py
git add  src/tests/test_services/test_token.py
git add  src/tests/test_units/test_users_crud.py
git commit -m "feat: implement generate and validate JWT"

本次目錄

.
├── Makefile
├── docker-compose.yml
├── pyproject.toml
├── requirements
│   ├── base.in
│   ├── base.txt
│   ├── development.in
│   └── development.txt
├── requirements.txt
├── setup.cfg
└── src
    ├── app
    │   ├── alembic.ini
    │   ├── api
    │   │   └── v1
    │   │       ├── endpoints
    │   │       │   └── auth
    │   │       │       └── users
    │   │       │           └── tokens.py  # 更新
    │   │       └── routers.py
    │   ├── crud
    │   ├── db
    │   │   └── bases.py
    │   ├── main.py
    │   ├── migrations
    │   │   ├── README
    │   │   ├── env.py
    │   │   ├── script.py.mako
    │   │   └── versions
    │   │       └── b130fb2851db_add_user_table.py
    │   ├── models
    │   │   └── auth.py
    │   └── schemas
    │       ├── health_check.py
    │       ├── tokens.py    # 新增
    │       └── users.py     # 新增
    ├── core
    │   └── config.py
    ├── scripts
    └── tests
        ├── conftest.py          # 新增
        ├── test_main.py
        ├── test_services
        │   └── test_token.py    # 更新
        └── test_units
            └── test_users_crud.py    # 拿掉共用的 fixtures

上一篇
使用者驗證 - 測試不可知的事務以符合真實情境 - 規劃
下一篇
使用者驗證 - 測試不可知的事務以符合真實情境 - 2
系列文
FastAPI 開發30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言